/* 
 Copyright 2007 Simon Lebettre
 This file is part of BeanCooker.

 BeanCooker is free software; you can redistribute it and/or modify
 it under the terms of the GNU Lesser General Public License as published by
 the Free Software Foundation; either version 3 of the License, or
 (at your option) any later version.

 BeanCooker is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU Lesser General Public License for more details.

 You should have received a copy of the GNU Lesser General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>
 */
package net.sf.beancooker;

import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import net.sf.beancooker.dataproviders.RandomDataProvider;

/**
 * BeanCooker automatically sets mock values to javabeans for tests / rapid
 * prototyping purpose. The simplest usage (default dataprovider) will set all
 * numeric values to 12345, all strings to "someString" , floats to 123.123, but
 * more advanced value generation are available in package 'dataproviders'
 */
@SuppressWarnings("unchecked")
public class BeanCooker {

	Logger logger = Logger.getLogger(this.getClass().getName());

	/** The rejected classes set. Classes you do not want to mock */
	private Set<Class> rejectedClassesSet = new HashSet<Class>();

	/** The data provider. Default is ArbitraryDataProvider, see the getter */
	private IDataProvider dataProvider;

	/**
	 * Mock a bean or any primitive / String object
	 * 
	 * @param input
	 *            Bean to mock, you must instanciate it before calling this
	 *            method.
	 * 
	 * @return the same Bean but with all values recursively set by the
	 *         configured dataprovider This is actually the same object
	 *         reference that the parameter, not a copy.
	 */
	public Object mock(Object input) {
		if (input instanceof Class) {
			throw new IllegalArgumentException(
					"BeanCooker.mock(input) takes an initilazed object, not a class");
		}
		return mock(input, 0);
	}

	/**
	 * Mock a bean or any primitive / String object
	 * 
	 * @param input
	 *            Bean to mock, you must instanciate it before calling this
	 *            method.
	 * @param index
	 *            for suffixing to strings values in collections
	 * 
	 * @return the same Bean but with all values recursively set by the
	 *         configured dataprovider This is actually the same object
	 *         reference that the parameter, not a copy.
	 */
	public Object mock(Object input, int index) {

		if (input == null || isRejected(input.getClass())) {
			return null;
		}
		// for simple types ( string, numerics and primitives) class.SimpleName
		// is used as field name.
		Object tmp = this.mockType(input.getClass(), input.getClass()
				.getSimpleName(), index);
		if (tmp != null) {
			// simple type like String, int, Integer ...
			input = tmp;
		} else {
			// bean

			Method[] methods = input.getClass().getMethods();

			for (Method method : methods) {

				if (method.getName().startsWith("set")) {

					Class[] params = method.getParameterTypes();
					String fieldName = getFieldNameFromMethod(method);

					Class clazz = params[0]; // COULDBEDONE multi param
					if (!isRejected(clazz)) {
						Class parentClass = input.getClass();
						Object o = mock(parentClass, clazz, index, fieldName);
						// XXX if input != null , what to do ?
						try {
							method.invoke(input, o);
						} catch (Exception e) {

							logger.log(Level.WARNING, e
									+ ", unable to invoke method "
									+ method.getName() + " with parameter "
									+ clazz);
						}
					}
				}
			}
		}
		return input;
	}

	private Object mock(Class parentClass, Class clazz, int index,
			String fieldName) {
		Object o;

		if (clazz.isArray()) {
			o = mockArray(clazz);
		} else if (clazz.isAssignableFrom(List.class)) {
			Class beanClazz = parentClass;
			o = mockList(beanClazz, fieldName);
		} else if (clazz.isAssignableFrom(Map.class)) {
			Class beanClazz = parentClass;
			o = mockMap(beanClazz, fieldName);
		} else {
			o = mockType(clazz, fieldName, index);
		}
		if (o == null) {
			o = instanciate(clazz);
			o = mock(o);
		}
		return o;
	}

	private Object mockMap(Class beanClazz, String fieldName) {

		Field f = getFieldByName(beanClazz, fieldName);

		ParameterizedType typeList = (ParameterizedType) f.getGenericType();
		Class keyClass = (Class) typeList.getActualTypeArguments()[0];
		Class ValueClass = (Class) typeList.getActualTypeArguments()[1];

		Map map = new HashMap();

		for (int i = 0; i < getDataProvider().getArraysSize(); i++) {
			Object key = instanciate(keyClass);

			// FORCE the use of i in fieldmap for map indexes
			key = mock(beanClazz, keyClass, i, fieldName + i);

			Object value = instanciate(ValueClass);
			value = mock(value, i);

			map.put(key, value);
		}

		return map;
	}

	/**
	 * Instanciate an object without throwing any exceptions.
	 * 
	 * will try to use a string constructor if clazz.newInstance() fails
	 * 
	 * @param clazz
	 *            the class to instanciate
	 * 
	 * @return the new object
	 */
	protected Object instanciate(Class clazz) {
		Object o;
		try {
			o = clazz.newInstance();
		} catch (Exception e) {
			o = tryWithStringConstructor(clazz);
		}

		return o;

	}

	/**
	 * Mock a type (any type but array and list). actually just a call to the
	 * dataprovider getValue()
	 * 
	 * This one is not recursive, recursivity is in mock method
	 * 
	 * @param clazz
	 *            the class to mock
	 * @param fieldName
	 *            the field name (to be able to use it in string values)
	 * @param index
	 *            the index (to be able to keep it in collections)
	 * 
	 * @return an object of the clazz type, with his value set by the
	 *         dataProvider .
	 * 
	 */
	protected Object mockType(Class clazz, String fieldName, int index) {
		return getDataProvider().getValue(clazz, fieldName, index);

	}

	/**
	 * Mocks an array of Objects or Primitives.
	 * 
	 * @param aClazz
	 *            the class to mock, wich is the Array Class or a primitive
	 *            array class.
	 * 
	 * @return the array object, with his values set by the dataProvider .
	 */
	protected Object mockArray(Class aClazz) {

		Class arrayContentClass = aClazz.getComponentType();
		Object arrayValue = null;
		if (arrayContentClass.isPrimitive()) {
			int arraySize = getDataProvider().getArraysSize();
			arrayValue = Array.newInstance(arrayContentClass, arraySize);
			for (int i = 0; i < arraySize; i++) {
				// use the simplename of the content class as fieldname (usefull
				// for advanced use of fieldname like in csv data provider)
				Array.set(arrayValue, i, this.getDataProvider()
						.getValue(arrayContentClass,
								arrayContentClass.getSimpleName(), i));
			}
		} else {
			Object[] array = (Object[]) Array.newInstance(arrayContentClass,
					getDataProvider().getArraysSize());

			if (!isRejected(arrayContentClass)) {
				for (int i = 0; i < array.length; i++) {
					try {
						Object oneEntry = instanciate(arrayContentClass);

						oneEntry = mock(oneEntry);

						array[i] = oneEntry;
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
			}
			arrayValue = array;
		}
		return arrayValue;

	}

	/**
	 * Mocks a List
	 * 
	 * @param beanClazz
	 *            the class whose the list is a field . Necessary to find the
	 *            list generic type by reflection on the List field.
	 * 
	 * @param fieldName
	 *            the name of the field that is a list.
	 * 
	 * @return the list, with his values set by the dataProvider .
	 */
	// use of untyped list of object + reflection
	private Object mockList(Class beanClazz, String fieldName) {

		Field f = getFieldByName(beanClazz, fieldName);
		ParameterizedType typeList = (ParameterizedType) f.getGenericType();
		Class classInsideList = (Class) typeList.getActualTypeArguments()[0];

		ArrayList list = new ArrayList();

		for (int i = 0; i < getDataProvider().getArraysSize(); i++) {
			Object oneEntry = instanciate(classInsideList);
			// recursive call
			oneEntry = mock(oneEntry);
			list.add(oneEntry);
		}
		return list;

	}

	/**
	 * Checks if a class is rejected ( = not to be mocked)
	 * 
	 * @param clazz
	 *            class to check
	 * 
	 * @return true, if clazz is rejected
	 */
	public boolean isRejected(Class clazz) {
		return this.getRejectedClassesSet().contains(clazz);
		// TODO test rejection system
	}

	/**
	 * Gets the data provider.
	 * 
	 * if it's null, sets it to a new ArbitraryDataProvider to have a default
	 * dataprovider.
	 * 
	 * @return the data provider
	 */
	public IDataProvider getDataProvider() {
		if (dataProvider == null) {
			setDataProvider(new RandomDataProvider());
		}
		return this.dataProvider;
	}

	/**
	 * Sets the data provider.
	 * 
	 * @param aDataProvider
	 *            the new data provider
	 */
	public void setDataProvider(IDataProvider aDataProvider) {
		this.dataProvider = aDataProvider;
		this.dataProvider.setCallback(this);
	}

	/**
	 * Try to instanciate a class with string constructor.
	 * 
	 * @param clazz
	 *            the class to instanciate
	 * 
	 * @return the new object instance, or null if it failed
	 */
	protected Object tryWithStringConstructor(Class clazz) {
		Object o = null;

		try {
			Constructor c = clazz.getConstructor(String.class);
			try {
				o = c.newInstance(this.getDataProvider().getStringValue());
			} catch (InvocationTargetException ite) {
				try {
					o = c.newInstance(this.getDataProvider()
							.getDecimalValueString());
				} catch (InvocationTargetException iteDecimal) {
					o = c.newInstance(this.getDataProvider()
							.getIntegerValueString());
				}
			}
		} catch (Exception e) {
			// TODO log ;
			e.printStackTrace();
		}

		return o;

	}

	/**
	 * Gets the field name from a getter / setter method.
	 * 
	 * @param method
	 *            the method
	 * 
	 * @return the field name from method
	 */
	private String getFieldNameFromMethod(Method method) {
		// remove the 'set' or 'get' part
		String fieldName = method.getName().substring(3);
		// change the first letter to lowercase
		fieldName = fieldName.substring(0, 1).toLowerCase()
				+ fieldName.substring(1);
		return fieldName;
	}

	/**
	 * Gets the field by its name.
	 * 
	 * @param clazz
	 *            the clazz which has the field
	 * @param fieldName
	 *            the field name
	 * 
	 * @return the field by name
	 */
	protected Field getFieldByName(Class clazz, String fieldName) {
		// with access to fields in super class hierarchy
		Field field = null;
		try {
			field = clazz.getDeclaredField(fieldName);
		} catch (NoSuchFieldException e) {

			Class superClass = clazz.getSuperclass();
			while (superClass != null) {
				try {
					field = superClass.getDeclaredField(fieldName);
					return field;
				} catch (NoSuchFieldException elow) {
					superClass = superClass.getSuperclass();
				}
			}
		}
		return field;
	}

	/**
	 * Gets the rejected classes set.
	 * 
	 * @return the rejected classes set
	 */
	public Set<Class> getRejectedClassesSet() {
		return this.rejectedClassesSet;
	}

	/**
	 * Sets the rejected classes set.
	 * 
	 * @param aRejectedClassesSet
	 *            the new rejected classes set
	 */
	public void setRejectedClassesSet(Set<Class> aRejectedClassesSet) {
		this.rejectedClassesSet = aRejectedClassesSet;
	}
}
